admin / Strike
publicWeb-Based UK Cyber Compliance Tool with Reporting
Strike / StrikeXi v3 / backend / app / risk.py
5912 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """ StrikeXi Risk Assessment engine (V2). Derives a qualitative risk assessment from the maturity scores produced by the scoring engine. Lower maturity => higher residual cyber risk. This translates the per-principle / per-objective maturity (0-100) into: * an overall risk rating (Critical / High / Medium / Low), * a per-objective risk breakdown, * a prioritised list of the highest-risk principles (key risk areas), and * a short narrative summary. It reads only persisted score snapshots, so it is stable for completed assessments and reflects exactly the responses the user gave. """ from sqlalchemy.orm import Session from . import models def _risk_band(score: float) -> dict: """Map a maturity score (0-100) to a residual-risk band.""" if score < 40: return {"level": "Critical", "rank": 4, "colour": "#c0392b"} if score < 60: return {"level": "High", "rank": 3, "colour": "#e67e22"} if score < 80: return {"level": "Medium", "rank": 2, "colour": "#f1c40f"} return {"level": "Low", "rank": 1, "colour": "#27ae60"} def _overall_band_from_rank(rank: float) -> dict: if rank >= 3.5: return {"level": "Critical", "colour": "#c0392b"} if rank >= 2.5: return {"level": "High", "colour": "#e67e22"} if rank >= 1.5: return {"level": "Medium", "colour": "#f1c40f"} return {"level": "Low", "colour": "#27ae60"} def build_risk_summary(db: Session, assessment: models.Assessment) -> dict: """Return a structured risk assessment derived from persisted scores.""" principles = {p.id: p for p in db.query(models.CafPrinciple).all()} # v3: objectives are framework-specific — derive the ordered list and titles # from the DB rather than assuming the four CAF objectives (A-D). framework_objectives = ( db.query(models.CafObjective) .filter(models.CafObjective.framework_id == assessment.framework_id) .order_by(models.CafObjective.sort_order) .all() ) obj_titles = {o.id: o.title for o in framework_objectives} obj_order = [o.id for o in framework_objectives] pscores = ( db.query(models.AssessmentPrincipleScore) .filter(models.AssessmentPrincipleScore.assessment_id == assessment.id).all() ) oscores = { s.objective_id: float(s.score) for s in db.query(models.AssessmentObjectiveScore) .filter(models.AssessmentObjectiveScore.assessment_id == assessment.id).all() } # Count answered questions to express assessment coverage. answered = ( db.query(models.AssessmentAnswer) .filter(models.AssessmentAnswer.assessment_id == assessment.id).count() ) # Per-principle risk rows principle_risks = [] for ps in pscores: pr = principles.get(ps.principle_id) score = float(ps.score) band = _risk_band(score) principle_risks.append({ "principle_id": ps.principle_id, "principle_title": pr.title if pr else "", "objective_id": pr.objective_id if pr else "", "score": score, "risk_level": band["level"], "risk_rank": band["rank"], "colour": band["colour"], }) # Per-objective risk (ordered by the framework's own objective order) objective_risks = [] for oid in obj_order: if oid not in oscores: continue score = oscores[oid] band = _risk_band(score) objective_risks.append({ "objective_id": oid, "title": obj_titles.get(oid, ""), "score": score, "risk_level": band["level"], "colour": band["colour"], }) # Overall risk = mean of objective risk ranks (so it reflects the spread, # not just the average score, giving weak areas due weight). if objective_risks: mean_rank = sum(_risk_band(o["score"])["rank"] for o in objective_risks) / len(objective_risks) else: mean_rank = 1.0 overall = _overall_band_from_rank(mean_rank) # Key risk areas = highest-risk principles first (lowest score), top 5. key_risks = sorted(principle_risks, key=lambda x: x["score"])[:5] counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0} for r in principle_risks: counts[r["risk_level"]] += 1 narrative = _narrative(overall["level"], counts, len(principle_risks)) return { "overall_risk": overall["level"], "overall_colour": overall["colour"], "answered_questions": answered, "principles_assessed": len(principle_risks), "counts": counts, "objective_risks": objective_risks, "principle_risks": sorted(principle_risks, key=lambda x: (x["objective_id"], x["principle_id"])), "key_risks": key_risks, "narrative": narrative, } def _narrative(overall_level: str, counts: dict, total: int) -> str: high = counts["Critical"] + counts["High"] if total == 0: return ("No principles have been scored yet, so a risk position cannot " "be determined. Complete the assessment to generate a risk profile.") base = (f"Based on the responses provided, the organisation's overall residual " f"cyber risk is assessed as {overall_level}. ") if counts["Critical"]: base += (f"{counts['Critical']} principle(s) fall into the Critical band and " f"require urgent remediation. ") if high: base += (f"{high} of {total} assessed principles present a High or Critical " f"residual risk and should be prioritised. ") else: base += ("No principles fall into the High or Critical risk bands. ") base += ("Addressing the key risk areas below, in priority order, will reduce " "residual risk and raise overall cyber maturity.") return base |